Repository Public
1"use client";2// Auteur : Simon-Pierre Boucher — contact@spboucher.ai3// Workflow d'une demande (client) : statut, priorité, assignation,4// confidentialité, tags, note interne, réponse au demandeur, archive/spam.5// Chaque action passe par l'API admin (revalidation RBAC serveur) puis6// rafraîchit la page serveur — pas d'état dupliqué.7import { useState } from "react";8import { useRouter } from "next/navigation";9import { PRIORITIES, CONFIDENTIALITY_LEVELS } from "@/lib/comms/categories";1011type Props = {12 id: number;13 statuses: Record<string, string>;14 status: string;15 priority: string;16 confidentiality: string;17 assignedTo: number | null;18 tags: string[];19 admins: { id: number; name: string }[];20 isArchived: boolean;21 isSpam: boolean;22 hasEmail: boolean;23};2425export default function SubmissionActions(p: Props) {26 const router = useRouter();27 const [busy, setBusy] = useState(false);28 const [error, setError] = useState("");29 const [note, setNote] = useState("");30 const [reply, setReply] = useState("");31 const [tagsText, setTagsText] = useState(p.tags.join(", "));32 const [sentInfo, setSentInfo] = useState("");3334 async function call(url: string, body: unknown): Promise<boolean> {35 setBusy(true);36 setError("");37 try {38 const res = await fetch(url, {39 method: url.includes("/notes") || url.includes("/reply") ? "POST" : "PATCH",40 headers: { "Content-Type": "application/json" },41 body: JSON.stringify(body),42 });43 const data = (await res.json().catch(() => ({}))) as { error?: string; sent?: boolean };44 if (!res.ok) {45 setError(data.error ?? "Action refusée.");46 setBusy(false);47 return false;48 }49 if (data.sent === false)50 setSentInfo("Message enregistré, mais l'envoi courriel a échoué — il reste dans la conversation.");51 router.refresh();52 setBusy(false);53 return true;54 } catch {55 setError("Réseau indisponible.");56 setBusy(false);57 return false;58 }59 }6061 const base = `/api/admin/submissions/${p.id}`;62 const sel = "field cursor-pointer !py-[8px] !text-[13px]";6364 return (65 <div className="flex flex-col gap-5">66 <div className="grid grid-cols-2 gap-3">67 <div>68 <label className="klabel mb-[4px] block">Statut</label>69 <select70 className={sel}71 value={p.status}72 disabled={busy}73 onChange={(e) => call(base, { status: e.target.value })}74 >75 {Object.entries(p.statuses).map(([k, v]) => (76 <option key={k} value={k}>{v}</option>77 ))}78 </select>79 </div>80 <div>81 <label className="klabel mb-[4px] block">Priorité</label>82 <select83 className={sel}84 value={p.priority}85 disabled={busy}86 onChange={(e) => call(base, { priority: e.target.value })}87 >88 {Object.entries(PRIORITIES).map(([k, v]) => (89 <option key={k} value={k}>{v}</option>90 ))}91 </select>92 </div>93 <div>94 <label className="klabel mb-[4px] block">Assignée à</label>95 <select96 className={sel}97 value={p.assignedTo ?? ""}98 disabled={busy}99 onChange={(e) =>100 call(base, { assigned_to: e.target.value ? Number(e.target.value) : null })101 }102 >103 <option value="">Personne</option>104 {p.admins.map((a) => (105 <option key={a.id} value={a.id}>{a.name}</option>106 ))}107 </select>108 </div>109 <div>110 <label className="klabel mb-[4px] block">Confidentialité</label>111 <select112 className={sel}113 value={p.confidentiality}114 disabled={busy}115 onChange={(e) => call(base, { confidentiality: e.target.value })}116 >117 {Object.entries(CONFIDENTIALITY_LEVELS).map(([k, v]) => (118 <option key={k} value={k}>{v}</option>119 ))}120 </select>121 </div>122 </div>123124 <div>125 <label className="klabel mb-[4px] block" htmlFor="w-tags">Tags (séparés par des virgules)</label>126 <div className="flex gap-2">127 <input128 id="w-tags"129 className="field !py-[8px] !text-[13px]"130 value={tagsText}131 onChange={(e) => setTagsText(e.target.value)}132 placeholder="ex. à-suivre, q1-2027"133 />134 <button135 className="btn btn-ghost !min-h-[38px] !px-4 !text-[12.5px]"136 disabled={busy}137 onClick={() =>138 call(base, { tags: tagsText.split(",").map((t) => t.trim()).filter(Boolean) })139 }140 >141 OK142 </button>143 </div>144 </div>145146 <div className="flex flex-wrap gap-2">147 <button148 className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"149 disabled={busy}150 onClick={() => call(base, { is_archived: !p.isArchived })}151 >152 {p.isArchived ? "Désarchiver" : "Archiver"}153 </button>154 <button155 className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"156 disabled={busy}157 onClick={() => call(base, { is_spam: !p.isSpam })}158 >159 {p.isSpam ? "Non-spam" : "Marquer spam"}160 </button>161 <button162 className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]"163 disabled={busy}164 onClick={() => call(base, { is_read: false })}165 >166 Marquer non lue167 </button>168 </div>169170 <div className="rule-ink pt-4">171 <label className="klabel mb-[4px] block" htmlFor="w-note">172 Note interne (jamais visible du demandeur)173 </label>174 <textarea175 id="w-note"176 rows={3}177 className="field resize-y !text-[13px]"178 value={note}179 onChange={(e) => setNote(e.target.value)}180 />181 <button182 className="btn btn-ghost mt-2 !min-h-[36px] !px-4 !text-[12.5px]"183 disabled={busy || !note.trim()}184 onClick={async () => {185 if (await call(`${base}/notes`, { body: note })) setNote("");186 }}187 >188 Ajouter la note189 </button>190 </div>191192 <div className="rule-ink pt-4">193 <label className="klabel mb-[4px] block" htmlFor="w-reply">194 Répondre au demandeur {p.hasEmail ? "(courriel via la conversation)" : ""}195 </label>196 {p.hasEmail ? (197 <>198 <textarea199 id="w-reply"200 rows={5}201 className="field resize-y !text-[13px]"202 value={reply}203 onChange={(e) => setReply(e.target.value)}204 placeholder="Votre réponse — elle est enregistrée dans la conversation et envoyée par courriel avec la référence."205 />206 <button207 className="btn btn-primary mt-2 !min-h-[38px] !px-5 !text-[13px]"208 disabled={busy || !reply.trim()}209 onClick={async () => {210 if (await call(`${base}/reply`, { body: reply })) setReply("");211 }}212 >213 Envoyer la réponse214 </button>215 </>216 ) : (217 <p className="gk-mono text-[11px] leading-relaxed text-ink-3">218 Soumission anonyme ou sans courriel — aucune réponse directe219 possible. Utilisez une note interne.220 </p>221 )}222 </div>223224 {sentInfo ? (225 <p className="gk-mono rounded-lg border-[1.5px] border-amber bg-amber-soft px-3 py-2 text-[11px] font-bold text-[#7a4d0d]">{sentInfo}</p>226 ) : null}227 {error ? (228 <p className="gk-mono rounded-lg border-[1.5px] border-danger bg-[var(--danger-soft)] px-3 py-2 text-[11px] font-bold text-danger">{error}</p>229 ) : null}230 </div>231 );232}233